Repository navigation
feat(orchestration): let a service claim its own nodes - #3654
Conversation
A submit config could already declare several services, but every one of them rendered as a bare `srun --overlap`, so they all landed on the same node and competed for its GPUs. A scorer or judge that cannot share a GPU with the policy had nowhere to go. A service may now name a `node_pool`. Pools take contiguous slices of the allocation in declaration order, and a pinned service is launched with `srun --relative=<offset>`, which starts the step at that slice. Sizing follows placement: a service pinned to a one-node pool inside a ten-node job is a single-node deployment, so it is no longer built as a multi-node Ray one, and its GPU footprint is checked against its own pool instead of the allocation total. This also fixes multiple pools being silently useless. The directives were emitted per pool, so each --nodes/--partition overwrote the last and only the final pool survived, while the executor sized itself on the sum. There is now one directive block for the allocation, and pools that disagree on a whole-allocation directive are refused instead of resolved by ordering. Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
`_build_ray_command` returned the fixed string "ray start --head". That starts
a cluster nobody can join, on whichever node srun picked, and returns
immediately -- ray daemonises, so the srun step exited and Slurm tore the
service down again.
A ray service now takes a mode. A head starts a cluster on a chosen port; a
worker joins one at `address`. Either may advertise custom Ray resources
(`{"extra_gpu": 4}`), which is how a benchmark asks for a node whose GPUs it
places actors on itself rather than letting Ray count them. --block keeps the
step alive for as long as the node should be up.
With node_pool from the previous commit, this is enough to put such a worker
on a node of its own.
Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
A worker could only join a fixed host:port, but the head's host is not known until Slurm places the job. A worker may now name a ray head service in the same config (`head: ray_head`); the job script exports that head's address as GYM_RAY_ADDRESS_<SERVICE>, resolved from its pool's first node, and the worker joins through it. The driver can read the same variable to connect. Also adds `extra_args` to ray services for flags the schema does not model, such as fixed ports that avoid another Ray instance on the same node. Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
--relative is only where Slurm starts looking. On a real two-node job a ray head with --relative=0 landed on node 1, next to the worker, because node 0's resources were held by the policy step; it then tried to reach node 0's IP and never came up. The job script now exports each pool's hosts (GYM_POOL_<NAME>_NODES) from the allocation's node list, and a pinned service passes them to --nodelist. The ray head address reuses the same host list. Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
With services pinned to pools, the driver's srun had no node constraint, and on a real two-node job Slurm ran it on the aux node. It reaches the policy on localhost and joins a Ray head through that node's raylet, so both failed. The driver now goes to the first node of the policy's pool. Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
The driver reaches the policy on localhost, but its srun only asked for one node, so in a multi-node job Slurm could start it anywhere. A multi-node policy serves its API from node 0, a pinned one from its pool's first node; the driver now goes there in any multi-node job, not only when services are pinned. An unpinned service that fits on one node follows it, for the same reason. Also brace the --nodelist expansion. Unbraced, $gym_nodes[0] expands to the first host followed by a literal [0]. Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
e9138e1 to
2acee87
Compare
| # "head" starts a cluster; "worker" joins the one at `address`. A worker is how a | ||
| # second node joins the driver's Ray cluster and offers its GPUs to actors the | ||
| # benchmark schedules (e.g. a scorer that runs off the policy's node). | ||
| mode: Literal["head", "worker"] = "head" |
There was a problem hiding this comment.
This seems like an implementation detail that should be hidden away, right? The user should rather specify how many nodes should cluster spanb (or in general a shape of ray cluster) but not start individual workers / head.
There was a problem hiding this comment.
I might have missed something. If you think that it's important, please provide an example of config where user would do separate head/worker definitions.
There was a problem hiding this comment.
Yes, this is too low-level, at least for now, since WMT is the only case I know and does not need it. I'll rework it into a single ray service that names the node pools it spans
| if service.mode == "head": | ||
| cmd += f" --head --port {service.port}" | ||
| elif service.head is not None: | ||
| # Double-quoted, not shlex-quoted: the head's address is only known once | ||
| # Slurm places the job, and build_sbatch_script exports it under this name. | ||
| cmd += f' --address "${ray_head_address_var(service.head)}"' | ||
| else: |
There was a problem hiding this comment.
This should be consolidated into a single ray service. Conceptually, worker belongs to head and I don't think it's going to be switched to another cluster
There was a problem hiding this comment.
Yes, will be covered like that
A ray service now names the node pools it spans instead of starting a head and workers separately. The head starts beside the driver, every other node of those pools joins it as a worker, and all of them use the service's one container. `resources` is keyed by pool, so only the pools that need a custom resource advertise it. Removes `mode`, `address` and `head`. Workers wait for the head, and flags Ray accepts only on a head are dropped from their command. Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
The health probe runs from the batch script on node 0, but a pinned service answers on its pool's first node, and a ray head and the collector follow the driver's node. Probed on localhost, they waited out their timeout on a later node. Probe them where _service_nodelist places them. Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
prokotg
left a comment
There was a problem hiding this comment.
Approved but agreed to carry automatic gpu placement in a follow-up PR
|
🔄 Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/36421636406 |
…Mo#3763) Same change as NVIDIA-NeMo#3657, rebased onto main on a new branch; NVIDIA-NeMo#3657's branch stays untouched for now. ## In one sentence Express prefill/decode disaggregated serving, so a policy can be split across two tiers behind a router. > Builds on NVIDIA-NeMo#3654 (per-service node placement, now on main): prefill and decode need node pools of their own. ## Why A large policy served for an agentic benchmark spends most of its time in prefill. Splitting prefill and decode onto separate node tiers, with KV cache transferred between them and a router in front, is how that workload is served in practice today. `SubmitConfig` had no way to say it. ## The change One new service type, `vllm_pd`. It nests its two tiers and owns the router and KV settings: ```yaml services: policy: type: vllm_pd container: vllm/vllm-openai:latest model: /models/checkpoint served_model_name: my-model port: 8000 # the router; driver.policy_model names this service prefill: node_pool: prefill tensor_parallel_size: 4 number_of_instances: 4 decode: node_pool: decode tensor_parallel_size: 4 number_of_instances: 6 # optional: server_per_node (false), nixl_side_channel_port (5600; decode +1), # prefill_policy, decode_policy, kv_connector (NixlConnector), # kv_load_failure_policy (fail), request_timeout_secs, log_level driver: policy_model: policy ``` - `prefill:` and `decode:` are plain vLLM service settings. Each inherits `container`, `model` and `served_model_name`. - Every P/D setting lives on the `vllm_pd` service. `server_per_node` applies to both tiers. `nixl_side_channel_port` is prefill's; decode gets it + 1. Setting either on a tier is refused. - Each tier gets its KV role automatically. - Tier ports default to router port + 1 and + 2. Decode's data-parallel port defaults to prefill's + 1. All can be set. Why `vllm_pd`: the service is a vLLM prefill/decode deployment, not a general router. A different kind of router can have its own type later. No logic is duplicated. At validation, the service expands into three internal services: `policy-prefill`, `policy-decode` and `policy` (the router). The existing multi-node rank builder, node placement and health checks run on those unchanged. The resolved config written to the job directory keeps the nested form, so it loads back. ## Bugs found by generating a real job script Before running anything, we generated the job script for SWE-bench Pro's real layout (4 prefill nodes, 6 decode nodes) and read it. That found three bugs, all still guarded: - **Both tiers used the same meeting point.** Each tier's vLLM servers now coordinate on their own first node and their own port. - **Decode's health check looked in the wrong place.** Each tier and the router are now probed on the node they run on. - **Nothing stopped the two tiers from picking the same ports.** Validation refuses a data-parallel port clash and names both tiers. The NIXL ports are derived, so they cannot clash. ## Found on a real cluster A SWE-bench Pro prefill/decode run on a real cluster exposed four more problems. All are fixed: - **One server per node.** A tier with `server_per_node: true` runs an independent `vllm serve` on every node, and the router lists each one. - **The router listens on `0.0.0.0`.** The driver and the health probe call it on `localhost`. - **The router and driver share node 0.** This comes from NVIDIA-NeMo#3654's placement rule. - **`driver.policy_host`.** An agent in a remote sandbox cannot reach the driver's `localhost`, so the injected policy URL can name a routable host. ## Testing - **Same job script as before.** One commit renders SWE-bench Pro's layout with the earlier three-service form and pins the output as fixtures. There are two variants: data-parallel tiers and one server per node. The next commit switches to `vllm_pd`, and the new form renders both fixtures byte for byte. The collector's scrape config is also unchanged. - **Unit tests.** Tests cover the expansion, inheritance and port defaults. They also check that the resolved config loads back. Refusal tests cover: a port clash, a tier without a pool or with an unknown pool, a missing tier, a name that clashes with a tier's name, KV fields or `server_per_node` / `nixl_side_channel_port` set on a tier, and those fields set on a plain vLLM service. - **Each new test was revert-checked.** It fails when the piece it guards is removed. - Non-P/D rendering is unchanged. ## Verified end to end SWE-bench Pro ran the full 4 prefill + 6 decode shape on a real cluster with this change. That was before the config was reshaped, and the job script is byte-identical. All 731 tasks x 3 = 2193 rollouts finished. Mean reward was 0.533, against a reference of 0.526 from the same checkpoint served another way. A deployment still needs its fabric environment (`UCX_*`, `NCCL_*`) supplied through each tier's `env` or `pre_command`. This PR does not presume a set. --------- Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
In one sentence
A service can now be given nodes of its own, instead of every service piling onto the same one.
The problem we hit
We moved a set of benchmarks from another launcher onto
gym eval submit, and two of them could not be expressed at all.The cause is the same in both:
SubmitConfigaccepts several services, but each one renders as a baresrun --overlapwith no node targeting. Every service starts on the same node and competes for its GPUs.What changed
A service may name a
node_pool. Pools take contiguous slices of the allocation in declaration order. The job script exports each pool's hosts, and a pinned service names them withsrun --nodelist.Sizing follows placement. A service pinned to a one-node pool inside a ten-node job is a single-node deployment, so it is no longer built as a multi-node Ray one, and its GPU footprint is checked against its own pool rather than the allocation total.
The ray service does something now.
_build_ray_commandreturned the fixed stringray start --head. A ray service now describes the cluster's shape: the node pools it spans, and customresourcesper pool, such as{"extra_gpu": 4}— which is what a benchmark asks for when it places actors on a node's GPUs itself (seeresources_servers/wmt_translation/app.py). The head starts beside the driver. Every other node of the spanned pools joins it as a worker, in the same container. Workers find the head through an address the job script exports once Slurm has placed the job.Placement follows the policy. The driver reaches the policy on
localhost, so in any multi-node job it runs on the policy's first node: node 0, or the first node of the policy's pool. An unpinned service that fits on one node goes there too.Found on a real cluster
The first version pinned services with
srun --relative. On oci-hsg that did not hold:--relativeis only where Slurm starts looking, and once a node runs a step, a later step moves elsewhere. A ray head asked for node 0 and landed on node 1; the driver landed away from the policy and could not reach it. Both are fixed by naming hosts with--nodelist. Verified with a two-node job where a COMET scorer ran on its own node and produced scores, and again after the ray service became one cluster over both pools.Two bugs this uncovered
#SBATCHdirectives were emitted once per pool, so each--nodes/--partitionoverwrote the last and only the final pool survived — while_node_totalsand everything downstream sized itself on the sum. A two-pool job asked Slurm for one pool's nodes. There is now one directive block for the allocation, and pools that disagree on a whole-allocation directive are refused rather than resolved by declaration order.ray startneeds--block. Ray daemonises and returns, so the srun step exited as soon as the node came up and Slurm tore the service down again.Testing
tests/unit_tests/test_slurm_script.pygains contiguous-range placement, the single-node-pinned-inside-a-multi-node-job case, unknown pool names, the pool-disagreement refusals, the ray service shape (head beside the driver, one worker step per spanned pool, per-pool resources), and health probes aimed at the node each service runs on. 355 orchestration tests pass; the failures in a fulltests/unit_testsrun aretest_super_vllm_launcher.py, which fails identically onmain.